feat: On-chain milestone arbitration — staked jurors resolve disputed rejections - #1088
Merged
Anuoluwapo25 merged 4 commits intoAug 21, 2026
Conversation
…voting Adds a milestone_arbitration Soroban contract so a rejected scholar can escalate to a panel of LRN-staking jurors instead of the single off-chain admin decision milestone-appeal.controller.ts currently makes final. - New milestone_arbitration contract: juror staking, weighted panel selection seeded from ledger data, commit-reveal voting, slashing, and a defined quorum-failure fallback. Full unit test coverage. - milestone_escrow gains an arbitration-authorized release path alongside the existing admin path; the arbitration contract address is changeable only through a queue/execute timelock, never a bare admin call. - Backend: migration for disputes/dispute_jurors/dispute_votes, indexer wiring for the dispute lifecycle events, REST endpoints, and notifications at each phase change. - Frontend: an escalate control on rejected milestones, a juror console (join pool, commit/reveal with locally-persisted salts), and a public dispute detail page.
EmmanuelOchaje
force-pushed
the
feat/issue-1082-milestone-arbitration
branch
from
August 20, 2026 16:43
f3865c0 to
88c6c53
Compare
The multilingual-content-translation PR merged into main first and claimed 031_translation_content_schema.sql. Renumbering the arbitration migration to 032 avoids stacking a third duplicate migration number on top of the two that already exist in this tree (027, 030).
9 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #1082.
Adds an on-chain arbitration path for disputed milestone rejections. Today,
milestone-appeal.controller.tsputs the final call in the hands of a single off-chain admin, andmilestone_escrow::release_trancheonly ever moves on that admin's signature — the most centralized point in a protocol whose pitch is "no gatekeepers." This PR gives a rejected scholar a credibly neutral alternative: escalate to a panel of LRN-staking jurors who vote commit-reveal, with the escrow release bound to their outcome and stakes slashed for voting against it (or for staying silent).What's here
Contract —
contracts/milestone_arbitration/join_panel/leave_panel— stake LRN to become eligible; leaving requires no active assignments so a juror can't dodge a slash mid-dispute.open_dispute— scholar-only (require_auth), stakes 500 LRN, draws a 5-juror panel immediately, weighted by stake and seeded from the ledger sequence/timestamp at draw time (not caller-controlled).commit_vote/reveal_vote— commit-reveal; a reveal that doesn't match its commitment is rejected, never silently dropped.resolve— callable by anyone once the panel has fully revealed or the reveal deadline passes. Tallies votes, slashes the minority and every non-revealer, and redistributes the slashed pool between the majority jurors and the winning party.Escrow integration —
contracts/milestone_escrow/release_tranche_via_arbitration(proposal_id, dispute_id): permissionless, but only succeeds if the arbitration contract's ownget_release_outcomeindependently confirms a favorable, quorum-met outcome for that proposal — and eachdispute_idcan only ever authorize one release.set_arbitration_contract, then changeable only throughqueue_arbitration_change/execute_arbitration_change, a 48h timelock mirroringupgrade_timelock_vault's pattern — never a bare admin call.Backend
031_milestone_arbitration.sql:disputes,dispute_jurors,dispute_votes, apending_dispute_evidencebridge table, and newdispute_*notification-preference columns.event-indexer.service.tsmapsDisputeOpened/VoteCommitted/VoteRevealed/DisputeResolvedinto the read model and fires notifications to the scholar and every panel member at each phase change (so a commit-reveal scheme doesn't quietly slash someone who was never told).GET /api/disputes,GET /api/disputes/:id,GET /api/disputes/juror/:address,GET /api/disputes/milestone/:proposalId/:milestoneId,POST /api/disputes/pending-evidence.pinata.service.tsupload endpoint; only its hash ever reaches the chain.Frontend
ScholarMilestones.tsx), stating the 500 LRN stake cost before signing./disputes— a juror console: stake to join the pool, see panel assignments, commit and reveal with a persisted salt (lost salt = lost vote, so it's saved locally between the two steps)./disputes/:id— a public detail page with phase, deadlines, vote tallies, and the resolution transaction link.Economics — the questions the issue asked to have answered
What stops a whale from dominating a panel? Selection weight per juror is capped (10,000 LRN), and the draw is without replacement, so one address can only ever hold one of the five seats regardless of stake size. Buying more stake raises the odds of being drawn, never the number of seats.
What stops a scholar from disputing indefinitely? Each rejected milestone can be disputed exactly once, disputing costs a real 500 LRN that's forfeited outright on a loss, and disputes must be opened within 7 days of the rejection. Every loss is a real, non-recoverable cost.
Parameters (documented with rationale in the contract's module doc comment): panel size 5, quorum 3, 500 LRN scholar stake, 1,000 LRN minimum juror stake, 7-day dispute window, 3-day commit window, 2-day reveal window, 50% minority slash, 100% non-participation slash, 70/30 split of the slashed pool between majority jurors and the winning party. These are starting points, not settled policy — happy to discuss before merge.
A known, honestly-scoped gap
The underlying rejection is still recorded off-chain (
milestone-appeal.controller.ts), soopen_disputehas no on-chain rejection record to check against —rejected_atis a caller-supplied timestamp that only rate-limits staleness, documented as such in the contract's doc comments. Closing this fully means the admin rejection path publishing something on-chain the arbitration contract can verify, which felt like a separate, larger change and is flagged as follow-up rather than smuggled in half-built.Test plan
cargo test --workspace— all green, including 31 new arbitration tests and 6 new escrow-integration testscargo fmt/cargo clippy --workspace --all-targets— clean on every file this PR touches (two pre-existing warnings elsewhere in the tree are untouched by this change)server:npm run build(tsc) — cleannpx tsc --noEmit,yarn lint,yarn build— all clean on every file this PR touches